Roads of the Roman Empire

The Itiner-e Digital Atlas — Routes, Stations & the Ancient Network

Author

Ryan Lafferty

Published

February 13, 2026

All Roads Lead to Rome

At its peak, the Roman road network stretched over 250,000 miles — an engineered web of stone and gravel that stitched together an empire spanning from the rain-soaked hills of Britannia to the sun-scorched deserts of Mesopotamia. These were not mere footpaths: they were graded, drained, and surfaced with polygonal basalt blocks, designed to move legions at speed and hold together the most complex administrative state the ancient world had ever known. The viae publicae — the great trunk roads like the Via Appia, the Via Egnatia, and the Via Domitia — served as the arteries of empire, carrying soldiers, merchants, tax collectors, and couriers across landscapes that had never before seen a straight line.

The data in this analysis comes from the Itiner-e digital atlas of ancient Roman roads (Brughmans, de Soto, Pazout & Bjerregaard Vahlstrup, 2024), a collaborative project by Aarhus University that reconstructs the network from archaeological evidence, historical itineraries, and the Pleiades ancient places gazetteer. Licensed CC BY 4.0.


The Road Network

Show code
import sys, platform
sys.path.append("..")

import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
import json

from db_connection import query_to_geodataframe, query_to_dataframe
from map_builder import (
    create_base_map, add_geodataframe_layer, add_point_markers,
    finalize_map, build_photo_popup,
)

# Bounding box: West 5.74, South 36.71, East 29.04, North 48.47
BBOX = "ST_MakeEnvelope(5.743307, 36.706750, 29.040825, 48.474916, 4326)"

# Load road segments within bounding box (simplified for performance)
segments = query_to_geodataframe(f"""
    SELECT segment_id, name, road_type, segment_certainty,
           construction_period, itinerary, description,
           length_m, lower_date, upper_date, source_url,
           ST_Simplify(geometry, 0.005) AS geom
    FROM roman_road_segments
    WHERE ST_Intersects(geometry, {BBOX})
""", geom_col="geom", crs=4326)

# Load places within bounding box (major settlements, forts, bridges only)
places = query_to_geodataframe(f"""
    SELECT pleiades_id, name, place_type, start_year, end_year, url,
           geometry AS geom
    FROM roman_road_places
    WHERE ST_Intersects(geometry, {BBOX})
      AND place_type IN ('major-settlement', 'fort', 'bridge')
""", geom_col="geom", crs=4326)

# Load user-curated POIs
with open("roman-roads-poi.json", "r", encoding="utf-8") as f:
    poi_data = json.load(f)

print(f"{len(segments)} road segments loaded")
print(f"{len(places)} Pleiades places loaded")
print(f"{len(poi_data)} user POIs loaded")
5201 road segments loaded
1438 Pleiades places loaded
5 user POIs loaded

By Road Type

Show code
type_counts = (
    segments.groupby("road_type")
    .size()
    .reset_index(name="count")
    .sort_values("count", ascending=False)
)
type_counts
road_type count
3 Secondary Road 2613
0 Main Road 2016
1 River 286
2 Sea Lane 286

By Construction Period

Show code
period_counts = (
    segments.groupby("construction_period")
    .size()
    .reset_index(name="count")
    .sort_values("count", ascending=False)
)
period_counts
construction_period count
64 Manius Aquilius (129-126 BCE) 78
104 Trajan (98-117 CE) 75
46 Gnaeus Egnatius (146 BCE) 52
85 Septimius Severus Caracalla and Geta (201 CE) 45
73 Maximinus Thrax (235-238 CE) 44
... ... ...
31 Constantine the Great (326 CE) 1
48 Hadrian (115-116 CE) 1
86 Septimius Severus Caracalla and Geta (210 CE) 1
96 Tetrarchy (306 CE) 1
99 Titus Quintius Flaminius (123 BCE) 1

112 rows × 2 columns


Stationes and Mansiones

Show code
# Display places table
display_places = places[["name", "place_type", "start_year", "end_year"]].copy()
display_places.columns = ["Name", "Type", "From", "To"]
display_places.sort_values("Name").head(30)
Name Type From To
828 Sardis/Hyde? major-settlement -750.0 640.0
1072 "Ponte Sanguinario" at Spoletium bridge -30.0 640.0
116 "Trajan's Bridge" at Drobeta-Turnu Severin bridge -30.0 640.0
873 (H)Alesion Pedion bridge -330.0 300.0
897 (H)Enna major-settlement -550.0 640.0
158 (L)Ibida fort 300.0 640.0
1435 (Mons) Brisiacus fort -30.0 640.0
963 *Agruvium major-settlement -330.0 640.0
582 *Aureus Mons fort -30.0 640.0
1301 *Aurinia/Saturnia major-settlement -750.0 640.0
1025 *Brigobannis fort -30.0 300.0
150 *Caput Bovis fort -30.0 640.0
120 *Diana fort -30.0 640.0
944 *Foetes fort 300.0 640.0
643 *Malves(i)a major-settlement 1700.0 2100.0
30 *Matar major-settlement -330.0 640.0
786 *Ponte Navata fort -30.0 640.0
575 *Una fort -30.0 640.0
1230 Abella major-settlement -330.0 640.0
1226 Abellinum major-settlement NaN NaN
506 Abrittus major-settlement -330.0 640.0
728 Abrud fort -30.0 300.0
1228 Acerrae major-settlement -330.0 640.0
57 Acharnai (N) major-settlement -550.0 300.0
738 Acidava fort -30.0 300.0
984 Acumincum fort -30.0 640.0
617 Ad Aquas? fort -30.0 640.0
1027 Ad Fines fort -30.0 640.0
760 Ad Flexum fort -30.0 640.0
621 Ad Herculem fort -30.0 640.0

The Empire’s Road Map

Show code
import folium

# Base map centered on study area (positron for European geography)
m = create_base_map(center=[42.59, 17.39], zoom=5, tiles="positron")

# --- Road segments by type ---
road_colors = {
    "Main Road": {"color": "#f97316", "weight": 3, "opacity": 0.9},
    "Secondary Road": {"color": "#d97706", "weight": 2, "opacity": 0.8},
    "Sea Lane": {"color": "#64748b", "weight": 2, "opacity": 0.6, "dashArray": "8 4"},
    "River": {"color": "#0ea5e9", "weight": 2, "opacity": 0.7},
}

for road_type, style in road_colors.items():
    subset = segments[segments["road_type"] == road_type]
    if len(subset) > 0:
        m = add_geodataframe_layer(
            m, subset,
            name=road_type,
            tooltip_fields=["name", "road_type", "construction_period"],
            style=style,
            highlight={"weight": style.get("weight", 2) + 2, "opacity": 1},
        )

# Remaining road types (if any not in the dict above)
known_types = set(road_colors.keys())
other = segments[~segments["road_type"].isin(known_types)]
if len(other) > 0:
    m = add_geodataframe_layer(
        m, other,
        name="Other Roads",
        tooltip_fields=["name", "road_type", "construction_period"],
        style={"color": "#a855f7", "weight": 1.5, "opacity": 0.6},
    )

# --- Places as GeoJSON layer (major settlements, forts, bridges) ---
m = add_geodataframe_layer(
    m, places,
    name="Pleiades Places",
    tooltip_fields=["name", "place_type"],
    style={"color": "#f43f5e", "weight": 0.5, "fillOpacity": 0.4, "radius": 3},
    show=False,
)

# --- User-curated POIs with photo support ---
for poi in poi_data:
    popup_html = build_photo_popup(
        poi,
        photo_url=poi.get("photo_url") or None,
        photo_caption=poi.get("photo_caption") or None,
    )
    folium.Marker(
        location=[poi["lat"], poi["lon"]],
        popup=folium.Popup(popup_html, max_width=450),
        tooltip=f"<b>{poi['name']}</b>",
        icon=folium.Icon(color="red", icon="star", prefix="fa"),
    ).add_to(m)

m = finalize_map(m)
m
Make this Notebook Trusted to load map: File -> Trust Notebook

Points of Interest

User-curated points of interest with photos and descriptions. Add entries to roman-roads-poi.json to populate this section.

Show code
if poi_data:
    poi_df = pd.DataFrame(poi_data)
    display_poi = poi_df[["name", "category", "description"]].copy()
    display_poi.columns = ["Name", "Category", "Description"]
    display_poi.style.set_properties(
        subset=["Description"], **{"min-width": "350px", "white-space": "normal"}
    )
else:
    print("No POIs added yet — edit roman-roads-poi.json to get started.")

Construction Timeline

Show code
import plotly.express as px

# Filter to segments with date information
dated = segments.dropna(subset=["lower_date"]).copy()
dated = dated[dated["lower_date"] != 0]

if len(dated) > 0:
    # Aggregate by road_type and lower_date for a manageable chart
    date_summary = (
        dated.groupby(["road_type", "lower_date"])
        .size()
        .reset_index(name="segment_count")
    )

    fig = px.scatter(
        date_summary,
        x="lower_date",
        y="road_type",
        size="segment_count",
        color="road_type",
        title="Road Construction by Period",
        labels={"lower_date": "Date (BCE/CE)", "road_type": "Road Type",
                "segment_count": "Segments"},
        color_discrete_map={
            "Main Road": "#f97316",
            "Secondary Road": "#d97706",
            "Sea Lane": "#64748b",
            "River": "#0ea5e9",
        },
    )
    fig.update_layout(height=400, showlegend=True)
    fig.show()
else:
    print("No dated segments in the study area.")

Static Overview

Show code
import contextily as cx

# Reproject to Web Mercator for contextily basemap tiles
segments_3857 = segments.to_crs(epsg=3857)

fig, ax = plt.subplots(1, 1, figsize=(14, 10))

color_map = {
    "Main Road": ("#f97316", 0.8, 0.9),
    "Secondary Road": ("#d97706", 0.4, 0.8),
    "Sea Lane": ("#64748b", 0.4, 0.6),
    "River": ("#0ea5e9", 0.4, 0.7),
}

for road_type, (color, lw, alpha) in color_map.items():
    subset = segments_3857[segments_3857["road_type"] == road_type]
    if len(subset) > 0:
        subset.plot(ax=ax, color=color, linewidth=lw, label=road_type, alpha=alpha)

# Remaining types
other_3857 = segments_3857[~segments_3857["road_type"].isin(color_map.keys())]
if len(other_3857) > 0:
    other_3857.plot(ax=ax, color="#a855f7", linewidth=0.3, label="Other", alpha=0.5)

# Add hillshade/terrain basemap (Esri — no API key needed)
cx.add_basemap(ax, source=cx.providers.Esri.WorldShadedRelief, zoom=5)

ax.set_title("Roman Road Network — Study Area", fontsize=14)
ax.set_axis_off()
ax.legend(loc="lower left", fontsize=8, framealpha=0.9)
plt.tight_layout()
plt.show()


Data Source & Bibliography

Itiner-e Digital Atlas

Brughmans, T., de Soto, P., Pazout, A. and Bjerregaard Vahlstrup, P. (2024). Itiner-e: the digital atlas of ancient roads. Aarhus University. Licensed CC BY 4.0.

Website: https://www.itiner-e.org

Pleiades Gazetteer

Place data linked via the Pleiades ancient world gazetteer (https://pleiades.stoa.org), a community-built geographic information system for ancient places.


Technical Details

Environment

Show code
print("Python:", sys.version.split()[0])
print("Platform:", platform.platform())
print("GeoPandas:", gpd.__version__)
print("Pandas:", pd.__version__)
Python: 3.11.9
Platform: Windows-10-10.0.26100-SP0
GeoPandas: 1.1.2
Pandas: 2.2.2

Data Summary

Show code
print(f"Road segments (study area): {len(segments)}")
print(f"Pleiades places (study area): {len(places)}")
print(f"User POIs: {len(poi_data)}")
print(f"CRS: {segments.crs}")
print(f"\nBounding box: W 5.74, S 36.71, E 29.04, N 48.47")
Road segments (study area): 5201
Pleiades places (study area): 1438
User POIs: 5
CRS: EPSG:4326

Bounding box: W 5.74, S 36.71, E 29.04, N 48.47

Feature Breakdown

Show code
type_cert = (
    segments.groupby(["road_type", "segment_certainty"])
    .size()
    .reset_index(name="count")
    .sort_values("count", ascending=False)
)
type_cert
road_type segment_certainty count
7 Secondary Road Conjectured 2446
1 Main Road Conjectured 1874
5 Sea Lane Conjectured 286
3 River Conjectured 221
0 Main Road Certain 138
6 Secondary Road Certain 107
4 River Hypothetical 65
8 Secondary Road Hypothetical 60
2 Main Road Hypothetical 4
Show code
place_type_counts = (
    places.groupby("place_type")
    .size()
    .reset_index(name="count")
    .sort_values("count", ascending=False)
)
place_type_counts
place_type count
1 fort 576
2 major-settlement 571
0 bridge 291

Notes / Decision Log

  • Data source: Itiner-e nightly bulk NDJSON export, loaded via roman_roads_harvester.py into PostGIS (everglades_gis database)
  • Tables: roman_road_segments (LINESTRING, 4326), roman_road_places (POINT, 4326) — separate from Everglades tables
  • Bounding box: Study area filtered to core Roman Empire (W 5.74, S 36.71, E 29.04, N 48.47)
  • CRS: EPSG:4326 (WGS84) — native CRS of the Itiner-e data
  • Photo popups: POI data stored in roman-roads-poi.json, supports external image URLs via <img> tags
  • Next steps: Add POI photos, expand narrative sections, regional detail maps

Built with Quarto • Template: Quarto GIS Research Starter